Navigation Guide

Baselines uses Navigation 3 with type-safe route keys and an explicit back stack. Feature ViewModels send commands through Navigator; the shared app renders the resulting stack with NavDisplay.

Responsibilities

LocationResponsibility
toolkit:navigationNavigator, its app-scoped implementation, stack mutations, navigation analytics, and entry-provider contracts.
ui:navigationApp-specific NavRoute keys, AppNavRoutes, GetStartRoute, and saved-state integration.
ui:<feature>Screens, ViewModels, and NavEntryFactory contributions.
app:multiplatformInitialization, MainUiState, and the ComposeApp navigation display.

Navigator and the entry contracts use androidx.navigation3.runtime.NavKey. The app's serializable sealed NavRoute implements NavKey; keep app destinations in ui/navigation/AppNavRoutes.kt.

Register a Destination

Add a serializable route to the existing AppNavRoutes object:

kotlin
1@Serializable
2data object Profile : NavRoute

Register its content in the feature's UI module:

kotlin
1import dev.zacsweers.metro.ContributesTo
2import dev.zacsweers.metro.IntoSet
3import dev.zacsweers.metro.Provides
4import dev.zacsweers.metrox.viewmodel.metroViewModel
5import io.baselines.sample.ui.navigation.AppNavRoutes
6import io.baselines.toolkit.di.UiScope
7import io.baselines.toolkit.navigation.NavEntryFactory
8
9@ContributesTo(UiScope::class)
10interface ProfileUiModule {
11
12 @Provides
13 @IntoSet
14 fun provideProfileNavEntryFactory(): NavEntryFactory = {
15 entry<AppNavRoutes.Profile> {
16 ProfileRoute(metroViewModel())
17 }
18 }
19}

NavEntryFactory is an EntryProviderScope<NavKey>.() -> Unit and can register multiple destinations. NavModule combines the factory set into one UI-scoped NavEntryProvider. The shared app must include the feature module for Metro to discover its contributions; see Create New Screen.

Routes with Arguments

Use a serializable data class for a destination with input, for example inside AppNavRoutes:

kotlin
1@Serializable
2data class Details(val itemId: String) : NavRoute

The entry lambda receives the typed route directly. Supply its values through an assisted ViewModel factory:

kotlin
1entry<AppNavRoutes.Details> { route ->
2 val viewModel = assistedMetroViewModel<DetailsViewModel, DetailsViewModel.Factory> {
3 create(itemId = route.itemId)
4 }
5 DetailsRoute(viewModel)
6}

Here DetailsViewModel must use @AssistedInject with an @Assisted itemId: String constructor argument. Its nested Factory implements ManualViewModelAssistedFactory, declares fun create(itemId: String): DetailsViewModel, and uses @AssistedFactory, @ContributesIntoMap(AppScope::class), and @ManualViewModelAssistedFactoryKey. Use PlaygroundViewModel and PlaygroundUiModule as the existing assisted-injection reference.

Navigate from a ViewModel

Inject io.baselines.toolkit.navigation.Navigator. Commands complete synchronously and serialize concurrent calls. The backStack: StateFlow<List<NavKey>> exposes snapshots ordered from root to current destination.

kotlin
1// Append a destination.
2navigator.navigate(AppNavRoutes.Playground)
3
4// Remove the current destination, retaining the root.
5navigator.navigateBack()
6
7// Replace the entire stack, for example after completing a flow.
8navigator.navigate { listOf(AppNavRoutes.Home) }

navigate(route) appends even if the same route is already present. The default navigateBack() does nothing when the stack is empty or contains only its root.

For a custom back action, return the complete desired stack:

kotlin
1// Return to the last Home entry, if present.
2navigator.navigateBack { stack ->
3 val index = stack.indexOfLast { it == AppNavRoutes.Home }
4 if (index >= 0) stack.take(index + 1) else stack
5}

Both transformation APIs replace the stack with the returned list; they do not append or pop an additional entry. Use the callback's stack instead of reading backStack.value and modifying it later. This keeps a read-modify-write operation within the navigator's lock.

Transformation rules:

  • The callback runs once and receives a detached snapshot. Returned lists are copied before publication.
  • Keep callbacks short and free of side effects. Do not call other navigator commands from a callback.
  • Returning an equal stack has no effect, including on analytics.
  • A changed stack must be non-empty. Removing every route throws IllegalArgumentException.
  • A throwing callback or rejected result leaves the stack and analytics unchanged.

The app uses route values directly as entry keys. Equal route values share Navigation 3 entry identity and saved/ViewModel state. If repeated visits need independent state, include a serializable visit ID in the route and supply a different value for each visit.

Startup and Restoration

Change GetStartRoute in ui:navigation to choose the app's start screen. The default is Home.

  • While initialization runs, the app shows a loading screen.
  • If initialization fails, the failure screen lets the user retry.
  • After initialization succeeds, the app shows the restored navigation stack, or the start screen if no valid saved stack is available.

The running and failure screens live in app:multiplatform and need no route registration. See App Initialization for startup configuration and retries.

Navigation Analytics

NavigatorImpl commits a changed stack before tracking its destination. Initialization and navigate record AnalyticsEvents.PageView; navigateBack records AnalyticsEvents.NavigateBack. The event is followed by an update to ContextAnalyticsProperty.CurrentPage. Page names use the destination key's qualified class name, falling back to toString().

See Analytics Usage Guide for context ordering, and Adopt Template Updates for migration changes.